| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138 |
- import "../../../../index.css"
- import { Link, Meta, Title } from "@solidjs/meta"
- import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
- import {
- getStatsModelsComparisonData,
- type ModelUsagePoint,
- type StatsModelComparisonInput,
- type StatsModelComparisonEntry,
- } from "@opencode-ai/stats-core/domain/home"
- import { runtime } from "@opencode-ai/stats-core/runtime"
- import { createAsync, query, useParams, useSearchParams } from "@solidjs/router"
- import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
- import { getRequestEvent } from "solid-js/web"
- import {
- ComparisonCardsSection,
- comparisonHref,
- modelRefFromCatalog,
- uniqueComparisonPairs,
- type ComparisonModelRef,
- type ComparisonPair,
- } from "../../../../compare-cards"
- import {
- catalogSlug,
- findModelCatalogEntry,
- formatCatalogLabName,
- getModelCatalog,
- type ModelCatalog,
- type ModelCatalogEntry,
- } from "../../../../model-catalog"
- import {
- applyThemePreference,
- Footer,
- getGitHubStars,
- Header,
- isThemePreference,
- themeStorageKey,
- type HeaderLink,
- type ThemePreference,
- } from "../../../../stats-shell"
- const compareFallbackUrl = "https://stats.opencode.ai"
- const compareHeaderLinks: readonly HeaderLink[] = [
- { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
- { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" },
- { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" },
- { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
- { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" },
- ]
- const compareFooterLinks: readonly HeaderLink[] = [
- { href: import.meta.env.BASE_URL, label: "Data Home" },
- { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
- { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
- { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
- ]
- const heroLabs = [
- { lab: "deepseek", label: "DeepSeek" },
- { lab: "openai", label: "OpenAI" },
- { lab: "anthropic", label: "Anthropic" },
- ] as const
- const usageBarLimit = 60
- const comparisonModelLimit = 6
- type ComparisonModel = {
- name: string
- lab: string
- labName: string
- slug: string
- catalog: ModelCatalogEntry | null
- stats: StatsModelComparisonEntry | null
- }
- type ComparisonDirection = "higher" | "lower"
- type ComparisonDetailCell = {
- value: string
- unit?: string
- href?: string
- kind?: "boolean"
- score?: number
- trend?: number
- }
- type ComparisonDetailRow = {
- label: string
- direction?: ComparisonDirection
- cells: ComparisonDetailCell[]
- }
- type ComparisonDetailSection = {
- title: string
- badge?: string
- rows: ComparisonDetailRow[]
- usage?: ModelUsagePoint[][]
- }
- type ComparisonModelSelection = {
- lab: string
- slug: string
- catalog: ModelCatalogEntry | null | undefined
- }
- type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]]
- const getComparisonData = query(async (models: StatsModelComparisonInput[]) => {
- "use server"
- return runtime.runPromise(getStatsModelsComparisonData(models))
- }, "getStatsModelComparisonDetailData")
- export default function ModelComparePair() {
- const event = getRequestEvent()
- event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
- const params = useParams()
- const [searchParams] = useSearchParams<{ add?: string }>()
- const firstLabParam = createMemo(() => params.firstLab ?? "")
- const firstModelParam = createMemo(() => params.firstModel ?? "")
- const secondLabParam = createMemo(() => params.secondLab ?? "")
- const secondModelParam = createMemo(() => params.secondModel ?? "")
- const catalog = createAsync(() => getModelCatalog())
- const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam()))
- const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam()))
- const modelSelections = createMemo(() => {
- const selected: ComparisonModelSelection[] = [
- { lab: firstLabParam(), slug: firstModelParam(), catalog: firstCatalog() },
- { lab: secondLabParam(), slug: secondModelParam(), catalog: secondCatalog() },
- ...parseAdditionalModels(searchParams.add).map((model) => ({
- ...model,
- catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug),
- })),
- ]
- return selected
- .filter(
- (model, index) =>
- index < 2 ||
- selected.findIndex(
- (candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model),
- ) === index,
- )
- .slice(0, comparisonModelLimit) as [
- ComparisonModelSelection,
- ComparisonModelSelection,
- ...ComparisonModelSelection[],
- ]
- })
- const stats = createAsync(() => {
- const selected = modelSelections()
- if (catalog() === undefined || selected.some((model) => model.catalog === undefined))
- return Promise.resolve(undefined)
- return getComparisonData(
- selected.map((model) => ({
- provider: model.catalog?.lab ?? model.lab,
- model: model.catalog?.slug ?? model.slug,
- })),
- )
- })
- const githubStars = createAsync(() => getGitHubStars())
- const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
- const [highlightBest, setHighlightBest] = createSignal(true)
- const [addingModel, setAddingModel] = createSignal(false)
- const models = createMemo(
- () =>
- modelSelections().map((model, index) =>
- buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null),
- ) as ComparisonModels,
- )
- const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`)
- const description = createMemo(
- () =>
- `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`,
- )
- const canonicalPath = createMemo(
- () =>
- `${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug(
- models()[1].lab,
- )}/${catalogSlug(models()[1].slug)}`,
- )
- const canonicalUrl = createMemo(() =>
- new URL(
- canonicalPath(),
- event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href),
- ).toString(),
- )
- const detailSections = createMemo(() => buildComparisonDetailSections(models()))
- const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1]))
- const selectorModels = createMemo(() =>
- uniqueCatalogModels([...models().map(comparisonCatalogEntry), ...(catalog()?.models ?? [])]),
- )
- const selectedCatalogModels = createMemo(() => models().map(comparisonCatalogEntry))
- const canAddModel = createMemo(
- () =>
- models().length < comparisonModelLimit &&
- selectorModels().some((model) => !selectedCatalogModels().some((selected) => selected.id === model.id)),
- )
- const navigateToModels = (next: ModelCatalogEntry[]) => {
- if (typeof window === "undefined" || next.length < 2) return
- window.location.href = comparisonModelsHref(next)
- }
- const structuredData = createMemo(() =>
- JSON.stringify({
- "@context": "https://schema.org",
- "@type": "WebPage",
- name: title(),
- description: description(),
- url: canonicalUrl(),
- about: models().map((model) => ({
- "@type": "SoftwareApplication",
- name: model.name,
- applicationCategory: "AI model",
- provider: model.labName,
- })),
- }),
- )
- const updateThemePreference = (preference: ThemePreference) => {
- applyThemePreference(preference)
- setThemePreference(preference)
- if (typeof window === "undefined") return
- window.localStorage.setItem(themeStorageKey, preference)
- }
- onMount(() => {
- if (typeof window === "undefined") return
- const preference = window.localStorage.getItem(themeStorageKey)
- const nextPreference = isThemePreference(preference) ? preference : "system"
- applyThemePreference(nextPreference)
- setThemePreference(nextPreference)
- })
- return (
- <main data-page="stats" data-layout="compare-detail" data-theme={themePreference()}>
- <Title>{title()}</Title>
- <Meta name="description" content={description()} />
- <Meta name="robots" content={models().length > 2 ? "noindex,follow" : "index,follow"} />
- <Link rel="canonical" href={canonicalUrl()} />
- <Meta property="og:type" content="website" />
- <Meta property="og:site_name" content="OpenCode" />
- <Meta property="og:title" content={title()} />
- <Meta property="og:description" content={description()} />
- <Meta property="og:url" content={canonicalUrl()} />
- <Meta name="twitter:card" content="summary" />
- <Meta name="twitter:title" content={title()} />
- <Meta name="twitter:description" content={description()} />
- <script type="application/ld+json">{structuredData()}</script>
- <Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
- <div data-component="container">
- <div data-component="content">
- <ComparisonHero
- canAddModel={canAddModel()}
- highlightBest={highlightBest()}
- models={models()}
- onAddModel={() => setAddingModel(true)}
- onHighlightBestChange={() => setHighlightBest(!highlightBest())}
- />
- <Show when={addingModel()}>
- <CompareModelSelectModal
- blockedIds={selectedCatalogModels().map((model) => model.id)}
- label="Add model"
- models={selectorModels()}
- onClose={() => setAddingModel(false)}
- onSelect={(model) => {
- setAddingModel(false)
- navigateToModels([...selectedCatalogModels(), model])
- }}
- />
- </Show>
- <div
- data-component="compare-detail-table"
- data-model-count={models().length}
- style={`--compare-detail-grid: ${comparisonDetailGridTemplate(models().length)}`}
- >
- <div data-component="compare-detail-scroll">
- <ComparisonPairSelector catalogModels={selectorModels()} models={models()} />
- <Show
- when={stats() !== undefined}
- fallback={
- <section data-section="compare-detail-matrix">
- <div data-component="empty-state" data-compact="true">
- <strong>Loading comparison</strong>
- <p>Loading model stats.</p>
- </div>
- </section>
- }
- >
- <ComparisonDetailMatrix
- highlightBest={highlightBest()}
- modelCount={models().length}
- sections={detailSections()}
- />
- </Show>
- </div>
- </div>
- <ComparisonCardsSection
- pairs={relatedPairs()}
- title="Related comparisons"
- description="Other model pairs to check."
- variant="featured"
- />
- </div>
- <Footer
- themePreference={themePreference()}
- onThemePreferenceChange={updateThemePreference}
- links={compareFooterLinks}
- bridge={{ href: "#comparison", label: "MODEL COMPARISON" }}
- />
- </div>
- </main>
- )
- }
- function ComparisonHero(props: {
- models: readonly ComparisonModel[]
- canAddModel: boolean
- highlightBest: boolean
- onAddModel: () => void
- onHighlightBestChange: () => void
- }) {
- return (
- <section id="overview" data-section="compare-detail-hero">
- <nav data-component="compare-home-breadcrumb" aria-label="Breadcrumb">
- <a data-slot="compare-home-crumb" href={import.meta.env.BASE_URL}>
- Data
- </a>
- <span data-slot="compare-home-separator">/</span>
- <a data-slot="compare-home-crumb" href={`${import.meta.env.BASE_URL}compare`}>
- Compare
- </a>
- </nav>
- <div data-slot="compare-detail-hero-grid">
- <h1 aria-label={`Compare ${props.models.map((model) => model.name).join(", ")}`}>
- <span>Compare</span>
- <HeroModelStack />
- <span>AI models</span>
- </h1>
- <div data-slot="compare-detail-actions">
- <button
- type="button"
- data-slot="compare-detail-action"
- data-active={props.highlightBest ? "true" : undefined}
- aria-pressed={props.highlightBest}
- onClick={props.onHighlightBestChange}
- >
- <span data-slot="compare-detail-highlight-icon" aria-hidden="true">
- <i />
- <i />
- </span>
- <span>Highlight best</span>
- </button>
- <button
- type="button"
- data-slot="compare-detail-action"
- disabled={!props.canAddModel}
- aria-haspopup="dialog"
- onClick={props.onAddModel}
- >
- <span data-slot="compare-home-plus" aria-hidden="true">
- +
- </span>
- <span>Add model</span>
- </button>
- </div>
- </div>
- <div data-slot="compare-home-pattern" aria-hidden="true" />
- </section>
- )
- }
- function HeroModelStack() {
- return (
- <span data-slot="compare-home-avatar-stack" aria-hidden="true">
- <For each={heroLabs}>
- {(lab) => (
- <span data-slot="compare-home-avatar-frame">
- <LabLogo lab={lab.lab} label={lab.label} size="large" />
- </span>
- )}
- </For>
- </span>
- )
- }
- function ComparisonPairSelector(props: { catalogModels: ModelCatalogEntry[]; models: readonly ComparisonModel[] }) {
- const [activeIndex, setActiveIndex] = createSignal<number>()
- const selectedModels = createMemo(() => props.models.map(comparisonCatalogEntry))
- const activeSelected = createMemo(() => {
- const index = activeIndex()
- if (index === undefined) return undefined
- return selectedModels()[index]
- })
- const blockedIds = createMemo(() =>
- selectedModels()
- .filter((_, index) => index !== activeIndex())
- .map((model) => model.id),
- )
- const navigateToSelection = (index: number, model: ModelCatalogEntry) => {
- if (typeof window === "undefined") return
- window.location.href = comparisonModelsHref(
- selectedModels().map((selected, selectedIndex) => (selectedIndex === index ? model : selected)),
- )
- }
- return (
- <section data-section="compare-detail-selector" aria-label="Selected models">
- <div data-component="compare-detail-selector-grid">
- <div data-slot="compare-detail-selector-spacer" aria-hidden="true" />
- <For each={props.models}>
- {(model, index) => (
- <CompareDetailSelectButton
- model={model}
- label={`Model ${index() + 1}`}
- column={index()}
- last={index() === props.models.length - 1}
- expanded={activeIndex() === index()}
- onOpen={() => setActiveIndex(index())}
- />
- )}
- </For>
- </div>
- <Show when={activeSelected()}>
- {(selected) => (
- <CompareModelSelectModal
- blockedIds={blockedIds()}
- label={`Choose model ${(activeIndex() ?? 0) + 1}`}
- models={props.catalogModels}
- selected={selected()}
- onClose={() => setActiveIndex(undefined)}
- onSelect={(model) => {
- const index = activeIndex()
- if (index === undefined) return
- setActiveIndex(undefined)
- navigateToSelection(index, model)
- }}
- />
- )}
- </Show>
- </section>
- )
- }
- function CompareDetailSelectButton(props: {
- model: ComparisonModel
- label: string
- column: number
- last: boolean
- expanded: boolean
- onOpen: () => void
- }) {
- return (
- <button
- type="button"
- data-slot="compare-detail-select-model"
- data-column={props.column}
- data-last={props.last ? "true" : undefined}
- aria-label={props.label}
- aria-haspopup="dialog"
- aria-expanded={props.expanded}
- onClick={props.onOpen}
- >
- <LabLogo lab={props.model.lab} label={props.model.labName} size="small" />
- <span data-slot="compare-detail-select-name">{props.model.name}</span>
- <ChevronDownIcon />
- </button>
- )
- }
- function CompareModelSelectModal(props: {
- models: ModelCatalogEntry[]
- selected?: ModelCatalogEntry
- blockedIds: string[]
- label: string
- onClose: () => void
- onSelect: (model: ModelCatalogEntry) => void
- }) {
- let searchInput: HTMLInputElement | undefined
- const [search, setSearch] = createSignal("")
- const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "")
- const availableModels = createMemo(() =>
- uniqueCatalogModels([...(props.selected ? [props.selected] : []), ...props.models]).filter(
- (model) => !props.blockedIds.includes(model.id),
- ),
- )
- const filteredModels = createMemo(() => {
- const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean)
- if (terms.length === 0) return availableModels()
- return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term)))
- })
- const preview = createMemo(
- () => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0],
- )
- createEffect(() => {
- const models = filteredModels()
- if (models.some((model) => model.id === previewId())) return
- const selected =
- props.selected && models.some((model) => model.id === props.selected?.id) ? props.selected.id : undefined
- setPreviewId(selected ?? models[0]?.id ?? "")
- })
- createEffect(() => {
- if (typeof window === "undefined") return
- window.requestAnimationFrame(() => searchInput?.focus())
- })
- return (
- <div
- data-component="compare-model-modal-scrim"
- role="presentation"
- onClick={props.onClose}
- onKeyDown={(event) => {
- if (event.key !== "Escape") return
- props.onClose()
- }}
- >
- <div
- data-component="compare-model-modal"
- role="dialog"
- aria-modal="true"
- aria-label={props.label}
- onClick={(event) => event.stopPropagation()}
- >
- <div data-slot="compare-model-modal-list">
- <label data-slot="compare-model-modal-search">
- <span data-slot="compare-model-modal-search-icon" aria-hidden="true" />
- <input
- ref={searchInput}
- value={search()}
- placeholder="Search models"
- aria-label="Search models"
- onInput={(event) => setSearch(event.currentTarget.value)}
- />
- </label>
- <div data-slot="compare-model-modal-results">
- <Show
- when={filteredModels().length > 0}
- fallback={
- <div data-slot="compare-model-modal-empty">
- <strong>No models found</strong>
- <span>Try another search.</span>
- </div>
- }
- >
- <For each={filteredModels()}>
- {(model) => (
- <button
- type="button"
- data-slot="compare-model-modal-row"
- data-active={preview()?.id === model.id ? "true" : undefined}
- onMouseMove={() => setPreviewId(model.id)}
- onFocus={() => setPreviewId(model.id)}
- onClick={() => props.onSelect(model)}
- >
- <span data-slot="compare-model-modal-row-main">
- <ModelAvatar model={model} size="tiny" />
- <span>{model.name}</span>
- </span>
- <Show when={isFreeModel(model)}>
- <span data-slot="compare-model-modal-badge">Free</span>
- </Show>
- </button>
- )}
- </For>
- </Show>
- </div>
- </div>
- <div data-slot="compare-model-modal-divider" aria-hidden="true" />
- <Show when={preview()}>{(model) => <CompareModelDetail model={model()} />}</Show>
- </div>
- </div>
- )
- }
- function CompareModelDetail(props: { model: ModelCatalogEntry }) {
- return (
- <aside data-slot="compare-model-modal-detail">
- <header data-slot="compare-model-modal-detail-header">
- <ModelAvatar model={props.model} size="small" />
- <strong>{props.model.name}</strong>
- </header>
- <div data-slot="compare-model-modal-description">
- <p>
- {props.model.description ??
- `${props.model.name} is an AI model from ${formatCatalogLabName(props.model.lab)}.`}
- </p>
- <span aria-hidden="true" />
- </div>
- <dl data-slot="compare-model-modal-facts">
- <CompareModelFact label="Release" value={formatCatalogDate(props.model.releaseDate)} />
- <CompareModelFact label="Context" value={formatCatalogLimit(props.model.limit?.context)} />
- <CompareModelFact label="Input" value={formatCatalogUnitPrice(props.model.cost?.input)} />
- <CompareModelFact label="Output" value={formatCatalogUnitPrice(props.model.cost?.output)} />
- <CompareModelFact label="URL" value={formatModelUrl(props.model)} href={modelHref(props.model)} />
- </dl>
- </aside>
- )
- }
- function CompareModelFact(props: { label: string; value: string; href?: string }) {
- return (
- <div data-slot="compare-model-modal-fact">
- <dt>{props.label}</dt>
- <dd>
- <Show when={props.href} fallback={props.value}>
- {(href) => <a href={href()}>{props.value}</a>}
- </Show>
- </dd>
- </div>
- )
- }
- function ComparisonDetailMatrix(props: {
- sections: ComparisonDetailSection[]
- highlightBest: boolean
- modelCount: number
- }) {
- return (
- <section id="comparison" data-section="compare-detail-matrix" aria-label="Model comparison">
- <div data-component="compare-detail-matrix">
- <For each={props.sections}>
- {(section) => (
- <section data-slot="compare-detail-group" aria-label={section.title}>
- <ComparisonDetailSpacer modelCount={props.modelCount} />
- <div data-slot="compare-detail-label" data-heading="true">
- <strong>{section.title}</strong>
- <Show when={section.badge}>{(badge) => <span>{badge()}</span>}</Show>
- </div>
- <For each={Array.from({ length: props.modelCount })}>
- {(_, index) => (
- <div
- data-slot="compare-detail-value"
- data-column={index()}
- data-last={index() === props.modelCount - 1 ? "true" : undefined}
- data-heading="true"
- aria-hidden="true"
- />
- )}
- </For>
- <For each={section.rows}>
- {(row) => {
- const best = () => (props.highlightBest ? bestDetailCellIndex(row) : undefined)
- return (
- <>
- <div data-slot="compare-detail-label">{row.label}</div>
- <For each={row.cells}>
- {(cell, index) => (
- <ComparisonDetailValue
- best={best() === index()}
- cell={cell}
- column={index()}
- last={index() === props.modelCount - 1}
- />
- )}
- </For>
- </>
- )
- }}
- </For>
- <Show when={section.usage}>
- {(usage) => (
- <>
- <div data-slot="compare-detail-label" data-empty="true" />
- <For each={usage()}>
- {(modelUsage, index) => (
- <ComparisonUsageBars
- data={modelUsage}
- column={index()}
- last={index() === props.modelCount - 1}
- />
- )}
- </For>
- </>
- )}
- </Show>
- <ComparisonDetailSpacer modelCount={props.modelCount} />
- </section>
- )}
- </For>
- </div>
- </section>
- )
- }
- function ComparisonDetailSpacer(props: { modelCount: number }) {
- return (
- <>
- <div data-slot="compare-detail-label" data-spacer="true" aria-hidden="true" />
- <For each={Array.from({ length: props.modelCount })}>
- {(_, index) => (
- <div
- data-slot="compare-detail-value"
- data-column={index()}
- data-last={index() === props.modelCount - 1 ? "true" : undefined}
- data-spacer="true"
- aria-hidden="true"
- />
- )}
- </For>
- </>
- )
- }
- function ComparisonDetailValue(props: { cell: ComparisonDetailCell; best: boolean; column: number; last: boolean }) {
- return (
- <div
- data-slot="compare-detail-value"
- data-column={props.column}
- data-last={props.last ? "true" : undefined}
- data-best={props.best ? "true" : undefined}
- >
- <Show when={props.cell.href} fallback={<ComparisonCellContent cell={props.cell} />}>
- {(href) => (
- <a href={href()} data-slot="compare-detail-value-link">
- <ComparisonCellContent cell={props.cell} />
- </a>
- )}
- </Show>
- </div>
- )
- }
- function ComparisonCellContent(props: { cell: ComparisonDetailCell }) {
- return (
- <Show
- when={props.cell.kind === "boolean"}
- fallback={
- <span data-slot="compare-detail-value-main">
- <span>{props.cell.value}</span>
- <Show when={props.cell.unit}>{(unit) => <span data-slot="compare-detail-unit">{unit()}</span>}</Show>
- <Show when={props.cell.trend !== undefined}>
- <span
- data-slot="compare-detail-trend"
- data-trend={
- props.cell.trend && props.cell.trend > 0
- ? "up"
- : props.cell.trend && props.cell.trend < 0
- ? "down"
- : "flat"
- }
- >
- {props.cell.trend && props.cell.trend > 0 ? "+" : ""}
- {formatPercent(props.cell.trend ?? 0)}
- </span>
- </Show>
- </span>
- }
- >
- <span data-slot="compare-detail-boolean" data-value={props.cell.value.toLowerCase()}>
- {props.cell.value}
- </span>
- </Show>
- )
- }
- function ComparisonUsageBars(props: { data: ModelUsagePoint[]; column: number; last: boolean }) {
- const points = () => props.data.slice(-usageBarLimit)
- const max = () => Math.max(...points().map((point) => point.tokens), 0)
- return (
- <div
- data-slot="compare-detail-value"
- data-column={props.column}
- data-last={props.last ? "true" : undefined}
- data-chart="true"
- >
- <Show
- when={points().length > 0 && max() > 0}
- fallback={<span data-slot="compare-detail-no-chart">No trend data</span>}
- >
- <span data-slot="compare-detail-bars" aria-hidden="true">
- <For each={points()}>
- {(point) => <i style={{ height: `${Math.max(4, Math.round((point.tokens / max()) * 40))}px` }} />}
- </For>
- </span>
- <span data-slot="compare-detail-bar-dates">
- <span>{formatUsageDate(points()[0]?.date)}</span>
- <span>{formatUsageDate(points()[points().length - 1]?.date)}</span>
- </span>
- </Show>
- </div>
- )
- }
- function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) {
- return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} />
- }
- function LabLogo(props: { lab: string; label: string; size: "large" | "small" | "tiny" }) {
- const iconId = () => getProviderIconId(props.lab)
- return (
- <span data-slot="compare-home-avatar" data-lab={iconId()} data-size={props.size} aria-label={props.label}>
- <ProviderIcon aria-hidden="true" id={iconId()} />
- </span>
- )
- }
- function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) {
- if (!catalog) return undefined
- return findModelCatalogEntry(catalog, model, lab) ?? null
- }
- function parseAdditionalModels(value: string | undefined) {
- return (value ?? "")
- .split(",")
- .flatMap((entry) => {
- const parts = entry.split("/")
- if (parts.length !== 2) return []
- const lab = catalogSlug(parts[0])
- const slug = catalogSlug(parts[1])
- return lab && slug ? [{ lab, slug }] : []
- })
- .slice(0, comparisonModelLimit - 2)
- }
- function comparisonModelSelectionKey(model: ComparisonModelSelection) {
- return model.catalog?.id ?? `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
- }
- function comparisonModelsHref(models: ModelCatalogEntry[]) {
- const path = comparisonHref(modelRefFromCatalog(models[0]), modelRefFromCatalog(models[1]))
- const additional = models.slice(2).map((model) => `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`)
- return additional.length === 0 ? path : `${path}?add=${additional.join(",")}`
- }
- function comparisonDetailGridTemplate(modelCount: number) {
- return `minmax(0, var(--compare-detail-label-column)) repeat(${modelCount}, minmax(var(--compare-detail-model-column-min), 1fr))`
- }
- function buildComparisonModel(
- labParam: string,
- modelParam: string,
- catalog: ModelCatalogEntry | null,
- stats: StatsModelComparisonEntry | null,
- ): ComparisonModel {
- return {
- name: catalog?.name ?? stats?.model ?? formatParamName(modelParam),
- lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam),
- labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam),
- slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam),
- catalog,
- stats,
- }
- }
- function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry {
- if (model.catalog) return model.catalog
- return {
- id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`,
- lab: catalogSlug(model.lab),
- slug: catalogSlug(model.slug),
- name: model.name,
- modalities: { input: [], output: [] },
- openWeights: false,
- reasoning: false,
- toolCall: false,
- attachment: false,
- temperature: false,
- weights: [],
- benchmarks: [],
- }
- }
- function uniqueCatalogModels(models: ModelCatalogEntry[]) {
- return Object.values(
- models.reduce<Record<string, ModelCatalogEntry>>((result, model) => {
- result[model.id] = result[model.id] ?? model
- return result
- }, {}),
- )
- }
- function buildComparisonDetailSections(models: readonly ComparisonModel[]): ComparisonDetailSection[] {
- return [
- {
- title: "Overview",
- rows: [
- comparisonDetailRow(
- "Author",
- models.map((model) => linkedTextCell(model.stats?.author ?? model.labName, labHref(model.lab))),
- ),
- comparisonDetailRow(
- "Context length",
- models.map((model) => limitCell(model.catalog?.limit?.context)),
- "higher",
- ),
- comparisonDetailRow(
- "Reasoning",
- models.map((model) => booleanDetailCell(model.catalog?.reasoning)),
- "higher",
- ),
- comparisonDetailRow(
- "Input modalities",
- models.map((model) => textCell(formatCatalogModalities(model.catalog?.modalities.input ?? []))),
- ),
- comparisonDetailRow(
- "Output modalities",
- models.map((model) => textCell(formatCatalogModalities(model.catalog?.modalities.output ?? []))),
- ),
- comparisonDetailRow(
- "Providers",
- models.map((model) => linkedTextCell(model.labName, labHref(model.lab))),
- ),
- ],
- },
- {
- title: "Pricing",
- rows: [
- comparisonDetailRow(
- "Input",
- models.map((model) => priceCell(model.catalog?.cost?.input)),
- "lower",
- ),
- comparisonDetailRow(
- "Output",
- models.map((model) => priceCell(model.catalog?.cost?.output)),
- "lower",
- ),
- comparisonDetailRow(
- "Cached input",
- models.map((model) => priceCell(model.catalog?.cost?.cacheRead)),
- "lower",
- ),
- ],
- },
- {
- title: "Momentum",
- badge: "Last 2 mo",
- rows: [
- comparisonDetailRow(
- "Unique users",
- models.map((model) => usageMetricCell(model.stats?.totals.uniqueUsers)),
- "higher",
- ),
- comparisonDetailRow(
- "Completed sessions",
- models.map((model) => usageMetricCell(model.stats?.totals.sessions, "integer")),
- "higher",
- ),
- comparisonDetailRow(
- "Token share",
- models.map((model) => percentCell(model.stats?.tokenShare)),
- "higher",
- ),
- comparisonDetailRow(
- "Tokens",
- models.map((model) => tokenCell(model.stats?.totals.tokens, model.stats?.tokenChange)),
- "higher",
- ),
- ],
- usage: models.map((model) => model.stats?.usage ?? []),
- },
- ]
- }
- function comparisonDetailRow(
- label: string,
- cells: ComparisonDetailCell[],
- direction?: ComparisonDirection,
- ): ComparisonDetailRow {
- return { label, direction, cells }
- }
- function bestDetailCellIndex(row: ComparisonDetailRow) {
- if (!row.direction) return undefined
- const scored = row.cells.flatMap((cell, index) => (cell.score === undefined ? [] : [{ index, score: cell.score }]))
- if (scored.length < 2) return undefined
- const score =
- row.direction === "higher"
- ? Math.max(...scored.map((cell) => cell.score))
- : Math.min(...scored.map((cell) => cell.score))
- const best = scored.filter((cell) => cell.score === score)
- return best.length === 1 ? best[0].index : undefined
- }
- function buildRelatedPairs(
- catalog: ModelCatalog | undefined,
- first: ComparisonModel,
- second: ComparisonModel,
- ): ComparisonPair[] {
- const current = [comparisonRef(first), comparisonRef(second)] as const
- const alternatives = (catalog?.models ?? [])
- .filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id)
- .slice(0, 4)
- .map(modelRefFromCatalog)
- return uniqueComparisonPairs([
- ...alternatives.slice(0, 3).flatMap((model, index) => [
- { first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
- { first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
- ]),
- ]).slice(0, 6)
- }
- function comparisonRef(model: ComparisonModel): ComparisonModelRef {
- return {
- name: model.name,
- lab: model.lab,
- slug: model.slug,
- labName: model.labName,
- metric: model.stats ? `#${model.stats.rank}` : "Catalog",
- }
- }
- function textCell(value: string): ComparisonDetailCell {
- return { value }
- }
- function linkedTextCell(value: string, href: string): ComparisonDetailCell {
- return { value, href }
- }
- function booleanDetailCell(value: boolean | undefined): ComparisonDetailCell {
- if (value === undefined) return { value: "Unknown" }
- return { value: value ? "True" : "False", kind: "boolean", score: value ? 1 : 0 }
- }
- function limitCell(value: number | undefined): ComparisonDetailCell {
- return value === undefined ? { value: "Unknown" } : { value: formatTokens(value), score: value }
- }
- function priceCell(value: number | undefined): ComparisonDetailCell {
- return value === undefined ? { value: "Unknown" } : { value: formatModelPrice(value), unit: "/ 1M", score: value }
- }
- function usageMetricCell(value: number | undefined, format: "compact" | "integer" = "compact"): ComparisonDetailCell {
- if (value === undefined) return { value: "No usage" }
- return { value: format === "integer" ? formatInteger(value) : formatTokens(value), score: value }
- }
- function percentCell(value: number | undefined): ComparisonDetailCell {
- return value === undefined ? { value: "No usage" } : { value: formatPercent(value), score: value }
- }
- function tokenCell(value: number | undefined, trend: number | undefined): ComparisonDetailCell {
- if (value === undefined) return { value: "No usage" }
- return { value: formatTokens(value), score: value, trend }
- }
- function labHref(lab: string) {
- return `${import.meta.env.BASE_URL}${catalogSlug(lab)}`
- }
- function modelSearchText(model: ModelCatalogEntry) {
- return `${model.name} ${formatCatalogLabName(model.lab)} ${model.id}`.toLowerCase()
- }
- function isFreeModel(model: ModelCatalogEntry) {
- return model.cost?.input === 0 && model.cost.output === 0
- }
- function modelHref(model: ModelCatalogEntry) {
- return `${import.meta.env.BASE_URL}${model.lab}/${model.slug}`
- }
- function formatModelUrl(model: ModelCatalogEntry) {
- return `.../${model.slug}`
- }
- function formatParamName(value: string) {
- return value
- .replace(/[-_]/g, " ")
- .replace(/\b\w/g, (letter) => letter.toUpperCase())
- .trim()
- }
- function formatCatalogLimit(value: number | undefined) {
- return value === undefined ? "Unknown" : formatTokens(value)
- }
- function formatCatalogUnitPrice(value: number | undefined) {
- if (value === undefined) return "Unknown"
- return `${formatModelPrice(value)} / 1M`
- }
- function formatModelPrice(value: number) {
- if (value > 0 && value < 0.01) return `$${value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`
- return formatMoney(value)
- }
- function formatCatalogModalities(values: string[]) {
- if (values.length === 0) return "Unknown"
- return values
- .map((value) => value.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()))
- .join(", ")
- }
- function formatCatalogDate(value: string | undefined) {
- if (!value) return "Unknown"
- const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
- if (!match) return value
- const year = Number(match[1])
- const month = match[2] ? Number(match[2]) - 1 : 0
- const day = match[3] ? Number(match[3]) : 1
- return new Intl.DateTimeFormat("en", {
- month: match[2] ? "short" : undefined,
- day: match[3] ? "numeric" : undefined,
- year: "numeric",
- timeZone: "UTC",
- }).format(new Date(Date.UTC(year, month, day)))
- }
- function formatUsageDate(value: string | undefined) {
- if (!value) return ""
- const date = new Date(value)
- if (Number.isNaN(date.getTime())) return value
- return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", timeZone: "UTC" }).format(date).toUpperCase()
- }
- function formatTokens(value: number) {
- if (value >= 1_000_000_000_000)
- return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
- if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
- if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
- if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
- return String(Math.round(value))
- }
- function formatInteger(value: number) {
- return new Intl.NumberFormat("en").format(value)
- }
- function formatPercent(value: number) {
- return `${trimNumber(value, Math.abs(value) >= 10 ? 1 : 2)}%`
- }
- function formatMoney(value: number) {
- if (value >= 1_000_000) return `$${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
- if (value >= 1_000) return `$${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
- return `$${value.toFixed(value >= 10 ? 0 : 2)}`
- }
- function trimNumber(value: number, digits: number) {
- return Number(value.toFixed(digits)).toLocaleString("en")
- }
- function getProviderIconId(provider: string) {
- const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
- if (id === "moonshot") return "moonshotai"
- if (id === "zhipu") return "zhipuai"
- return id
- }
- function ChevronDownIcon() {
- return (
- <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
- <path d="M4.75 6.25L8 9.5L11.25 6.25" stroke="currentColor" stroke-width="1.5" />
- </svg>
- )
- }
|